원본 항목: Part 5. 코드 오류 — 1. 널(Null)포인터 역참조CWE-476repo 폴더: sd43_nullderef/ (빌드·실행은 §3-1)
목표 시간: 1회차 10분 / 2회차 6분 / 3회차 4분
Part 5(코드 오류, 9개)의 첫 드릴이다.
getenv()는 값이 없으면 NULL을 반환하는데, 실제 프로세스 환경변수 상태에 의존하면 테스트가 실행 환경마다 달라지므로 lookup_env_like()라는 결정론적 대역으로 같은 계약(못 찾으면 NULL)을 재현한다.
이 드릴의 문서 구성 (SD01~SD58 공통)
① 요구사항서 — 어떤 코드가 뚫려 있고, 무엇을 만드나.
② pseudo code 템플릿 — Bad 와 Good 의 뼈대이고, 회차마다 가린다.
③ 테스트 코드 — 공격은 막히고 정상은 통과해야 끝이다.
④ 자주 하는 실수
⑤ 모범답안 — 맨 뒤에 접어 둔다.
변형 A/B/C 는 같은 치환 규칙을 다른 소재로 다시 치는 것이다.
0. 이 드릴로 체화할 것
const char *p = lookup_env_like(key);- strncpy(out, p, out_size - 1); /* NULL 검사 없이 역참조 */+ if (p == NULL) return -1; /* 사용 전 NULL 검사 */+ strncpy(out, p, out_size - 1);
“이 함수가 실패하면 NULL을 반환할 수 있는가”를 팀 표준으로 정하고, 역참조 직전에 무조건 검사하는 습관을 들인다.
회차별 진행
회차
변형
보는 것
순서
1회차
A 환경설정 값 복사
① 요구사항서 ② pseudo code 전체
1. Bad 를 그대로 쳐서 취약점을 관찰한다. 2. Good pseudo 를 한 줄씩 코드로 바꾼다. 3. ③ 테스트를 실행한다.
2회차
B 구조체 멤버 포인터로 변형
① 요구사항서 ② pseudo code 의 번호와 제목만 (Level 2)
1. Bad 를 치고 취약점을 확인한다. 2. 번호만 보고 Good 을 쓴다. 3. ③ 테스트를 실행한다.
3회차
C 여러 단계 체이닝
① 요구사항서만
1. ③ 테스트를 먼저 읽는다. 2. 백지에서 Bad 부터 Good 까지 재현한다.
1회차에 막히면 같은 변형을 한 번 더 친다.
3회차를 통과하면 A 를 백지로 다시 친다.
정상 입력에서는 Bad도 문제없이 동작한다 — “항상 죽는다”가 아니라 “특정 입력에서만 죽는다”는 걸 확인
"MISSING_VAR"(Good)
-1 반환, 크래시 없음
역참조 전에 NULL 검사로 걸러낸다
1-B. 변형 B — 구조체 멤버 포인터로 변형
lookup_env_like가 문자열 대신 struct config *를 반환하는(못 찾으면 NULL) 형태로 바꿔, 포인터 멤버 접근(cfg->value) 전에 검사하는 패턴으로 확장한다
1-C. 변형 C — 여러 단계 체이닝
“설정 조회 → 그 결과로 다른 조회 → 최종 사용”까지 3단계로 늘려, 중간 단계 중 하나라도 NULL이면 전체가 안전하게 실패해야 하는 함수로 만든다
2. pseudo code 템플릿 — 보고 쳐서 익힌다
사용법
변형별로 아래 pseudo code 를 소스 파일에 주석만 복사한다.
코드는 주석을 보고 내가 쓴다.
다 쓰면 [ ] 를 x 로 바꾼다.
막히면 §5 모범답안(A)의 해당 단계만 본다.
2-A. copy_env_good — Level 1 (1회차, 전부 본다)
먼저 Bad 를 그대로 쳐서 §3 의 test_bad_is_vulnerable 이 통과하는지(= 실제로 뚫리는지) 확인한다.
그다음 Good 을 쓴다.
void copy_env_bad(const char *key, char *out, size_t out_size){ /* 1. p = lookup_env_like(key) * 2. strncpy(out, p, out_size - 1) ← NULL 검사 없이 역참조 * 3. out[out_size - 1] = '\0' */}int copy_env_good(const char *key, char *out, size_t out_size){ /* [ ] 1. p = lookup_env_like(key) * "HOME" 이면 "/home/appuser", 그 외에는 NULL */ /* [ ] 2. p == NULL 이면 → return -1 ← 사용 전 NULL 검사 */ /* [ ] 3. strncpy(out, p, out_size - 1) */ /* [ ] 4. out[out_size - 1] = '\0' */ /* [ ] 5. return 0 */}
2-B. copy_env_good — Level 2 (2회차, 번호와 제목만 본다)
int copy_env_good(const char *key, char *out, size_t out_size){ /* [ ] 1. p = lookup_env_like(key) */ /* [ ] 2. p == NULL 이면 → return -1 ← 사용 전 NULL 검사 */ /* [ ] 3. strncpy(out, p, out_size - 1) */ /* [ ] 4. out[out_size - 1] = '\0' */ /* [ ] 5. return 0 */}
2-C. copy_env_good — Level 3 (3회차, 백지)
int copy_env_good(const char *key, char *out, size_t out_size){ /* 아무것도 안 본다. * §1 요구사항서와 §0 의 치환 규칙만 보고 친다. * * 막히면 아래만 찾아본다. * - "실패하면 NULL 을 반환하는가" 를 먼저 묻는다 * - 역참조 **직전** 에 검사한다 */}
막혔을 때 찾는 순서
컴파일 에러가 나면 에러 문구를 본다.
unknown type name / implicit declaration 이면 include 누락이다.
man 3 <함수> 를 연다.
SYNOPSIS 맨 위의 #include 를 확인한다.
로직이 막히면 §3 테스트의 기대값을 먼저 읽는다.
공격 입력표(§1)와 테스트가 답이다.
#define _DEFAULT_SOURCE#include <stdio.h>#include <string.h>#include <sys/wait.h>#include <unistd.h>#include "nullderef.h"static int g_fail = 0;#define T_TRUE(cond, msg) \ do { if (!(cond)) { g_fail++; \ printf(" X %s:%d %s\n", __func__, __LINE__, msg); } } while (0)static int expect_crash(void (*fn)(void)){ pid_t pid = fork(); if (pid == 0) { fn(); _exit(0); } int status = 0; waitpid(pid, &status, 0); if (WIFSIGNALED(status)) return 1; if (WIFEXITED(status) && WEXITSTATUS(status) != 0) return 1; return 0;}static void call_bad_missing(void){ char out[64]; copy_env_bad("MISSING_VAR", out, sizeof(out));}static void test_bad_is_vulnerable(void){ T_TRUE(expect_crash(call_bad_missing) == 1, "Bad는 알 수 없는 키에서 NULL을 검사 없이 역참조해 크래시해야 한다(취약점 재현)");}static void test_bad_normal(void){ char out[64]; copy_env_bad("HOME", out, sizeof(out)); T_TRUE(strcmp(out, "/home/appuser") == 0, "알려진 키에서는 크래시 없이 정상 복사돼야 한다");}static void test_good_blocks_null(void){ char out[64]; T_TRUE(copy_env_good("MISSING_VAR", out, sizeof(out)) == -1, "Good은 NULL을 검사해 알 수 없는 키에서 실패를 반환해야 한다");}static void test_good_normal(void){ char out[64]; T_TRUE(copy_env_good("HOME", out, sizeof(out)) == 0, "알려진 키에서는 성공해야 한다"); T_TRUE(strcmp(out, "/home/appuser") == 0, "복사된 내용이 정확해야 한다");}int main(void){ test_bad_is_vulnerable(); test_bad_normal(); test_good_blocks_null(); test_good_normal(); printf(g_fail ? "FAIL %d\n" : "PASS\n", g_fail); return g_fail ? 1 : 0;}
이 크래시는 두 단계로 보고된다
make check를 돌리면 먼저 UBSan이 “strncpy의 두 번째 인자가 NULL이면 안 된다”(nonnull attribute)는 경고를 출력하고 계속 진행한다(UBSan은 기본적으로 복구 가능 모드).
그 직후 strncpy 내부가 실제로 주소 0을 읽으려다 진짜 SIGSEGV가 나고, 이걸 ASan이 잡아 프로세스를 중단시킨다.
두 리포트 모두 “실패가 아니라 증거”다 — SD17에서 본 “UBSan 리포트 + ASan abort” 조합과 같은 패턴이다.